--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 39d4cbe0d40791532d92431964f2ffd622478da0
Parents : 92dba25
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T08:22:36-05:00
feat(Network Visualiser): improve performance for realz this time
Changes
7 files changed, 111 insertions(+), 58 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index a011a00d..10046403 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -108,18 +108,21 @@ function yieldToMain() {
/*
* Pick a visualisation chunk size that scales down on weak hardware. ARM SBCs
- * commonly report 4 logical cores; phones/SoCs frequently report 2. We keep
- * desktop throughput (larger chunks => fewer yields) but drop hard for low
- * core-count devices so the main thread is not pinned for tens of ms per chunk.
+ * commonly report 4 logical cores; phones/SoCs frequently report 2. Desktop
+ * uses large chunks (fewer DataSet updates / yields) so wall-clock build time
+ * stays competitive with upstream's single-pass update.
*/
function pickAdaptiveChunkSize() {
const cores = (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4;
- if (cores <= 2) return 40;
- if (cores <= 4) return 80;
- if (cores <= 6) return 150;
- return 250;
+ if (cores <= 2) return 60;
+ if (cores <= 4) return 120;
+ if (cores <= 6) return 250;
+ return 500;
}
+/* Skip event-loop yields when the path table is small enough for one paint. */
+const VIZ_SYNC_PATH_THRESHOLD = 180;
+
/*
* Straight edges ({ enabled: false } object, never the boolean `false`). Boolean
* smooth breaks vis-network 9.x on later setOptions(); "continuous" curves
@@ -168,6 +171,7 @@ export default {
searchQuery: "",
hopMaxFilter: readStoredHopMaxFilter(),
hopFilterDebounceTimer: null,
+ searchDebounceTimer: null,
abortController: new AbortController(),
currentLOD: "high",
didDisableStabilization: false,
@@ -202,8 +206,12 @@ export default {
this.refreshPhysicsEnabled();
},
searchQuery() {
- // we don't want to trigger a full update from server, just re-run the filtering on existing data
- this.processVisualization();
+ // Debounce full rebuilds while typing; filter still runs on existing data.
+ if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
+ this.searchDebounceTimer = setTimeout(() => {
+ this.searchDebounceTimer = null;
+ this.processVisualization();
+ }, 120);
},
hopMaxFilter() {
if (this.hopFilterDebounceTimer) clearTimeout(this.hopFilterDebounceTimer);
@@ -228,6 +236,10 @@ export default {
clearTimeout(this.hopFilterDebounceTimer);
this.hopFilterDebounceTimer = null;
}
+ if (this.searchDebounceTimer) {
+ clearTimeout(this.searchDebounceTimer);
+ this.searchDebounceTimer = null;
+ }
if (this.lodRafId != null) {
cancelAnimationFrame(this.lodRafId);
this.lodRafId = null;
@@ -583,8 +595,10 @@ export default {
interaction: {
tooltipDelay: 100,
hover: true,
+ // Hide edges while dragging/zooming — biggest win vs upstream
+ // for large graphs (upstream redraws every edge every frame).
hideEdgesOnDrag: true,
- hideEdgesOnZoom: false,
+ hideEdgesOnZoom: true,
},
layout: {
randomSeed: 42,
@@ -594,21 +608,27 @@ export default {
enabled: this.enablePhysics,
solver: "barnesHut",
barnesHut: {
- gravitationalConstant: -10000,
- springConstant: 0.02,
+ // Match upstream gravity; avoidOverlap is O(n) per tick
+ // and is the main reason we felt slower than MeshChat.
+ gravitationalConstant: -5000,
+ springConstant: 0.04,
springLength: 200,
- damping: 0.4,
- avoidOverlap: 1,
+ damping: 0.35,
+ avoidOverlap: 0,
+ theta: 0.6,
},
stabilization: {
enabled: true,
- iterations: 150,
- updateInterval: 25,
+ iterations: 80,
+ updateInterval: 50,
},
+ maxVelocity: 50,
+ minVelocity: 0.75,
+ timestep: 0.5,
},
nodes: {
- borderWidth: 3,
- borderWidthSelected: 6,
+ borderWidth: 2,
+ borderWidthSelected: 4,
color: {
border: "#3b82f6",
background: isDarkMode ? "#1e40af" : "#eff6ff",
@@ -617,7 +637,7 @@ export default {
},
font: {
face: "Inter, system-ui, sans-serif",
- strokeWidth: 4,
+ strokeWidth: 3,
strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)",
},
// Canvas shadows are by far the most expensive per-node
@@ -627,8 +647,8 @@ export default {
},
edges: {
smooth: VIZ_EDGE_SMOOTH,
- selectionWidth: 3,
- hoverWidth: 2,
+ selectionWidth: 2,
+ hoverWidth: 1.5,
color: {
opacity: 0.6,
},
@@ -728,11 +748,23 @@ export default {
if (this.currentLOD === newLOD) return;
this.currentLOD = newLOD;
+ // Only mutate nodes whose LOD props actually change (avoids O(N)
+ // DataSet churn + full redraw when zooming across thresholds).
const allNodes = this.nodes.get();
- const updates = allNodes.map((node) => {
- return this.getNodeLODProps(node, newLOD);
- });
- this.nodes.update(updates);
+ const updates = [];
+ for (const node of allNodes) {
+ const next = this.getNodeLODProps(node, newLOD);
+ const shapeChanged = next.shape != null && next.shape !== node.shape;
+ const sizeChanged = next.size != null && next.size !== node.size;
+ const fontSize = next.font?.size;
+ const fontChanged = fontSize != null && fontSize !== (node.font?.size ?? null);
+ if (shapeChanged || sizeChanged || fontChanged) {
+ updates.push(next);
+ }
+ }
+ if (updates.length > 0) {
+ this.nodes.update(updates);
+ }
if (newLOD === "high" && this.iconQueue.length > 0) {
this.scheduleIconQueue();
@@ -765,9 +797,6 @@ export default {
opacity: 0.5,
};
},
- directEdgeArrows() {
- return { to: { enabled: true, scaleFactor: 0.5 } };
- },
interfaceDisplayLabel(name) {
if (!name) return "Interface";
const bracket = name.match(/\[([^\]]+)\]/);
@@ -1002,9 +1031,10 @@ export default {
color: isDarkMode ? "#f87171" : "#ef4444",
opacity: 1,
},
+ // Solid wider stroke (no arrows) — arrows are redrawn every
+ // physics frame and dominate canvas cost on large meshes.
width: 3,
length: 200,
- arrows: this.directEdgeArrows(),
hidden: false,
});
processedEdgeIds.add(edgeId);
@@ -1058,7 +1088,6 @@ export default {
color: this.directEdgeColor(isDarkMode),
width: 3,
length: 200,
- arrows: this.directEdgeArrows(),
hidden: false,
});
processedEdgeIds.add(edgeId);
@@ -1071,6 +1100,14 @@ export default {
const discoveredNodes = [];
const discoveredEdges = [];
if (this.showDiscoveredInterfaces) {
+ const activeEndpoints = new Set();
+ for (const a of this.discoveredActive) {
+ const aHost = a.target_host || a.remote || a.listen_ip;
+ const aPort = a.target_port || a.listen_port;
+ if (aHost && aPort != null) {
+ activeEndpoints.add(`${aHost}:${aPort}`);
+ }
+ }
for (const disc of this.discoveredInterfaces) {
const discId = `discovered~${disc.discovery_hash || disc.name}`;
const discLabel = disc.name || disc.reachable_on || "Unknown";
@@ -1086,11 +1123,10 @@ export default {
continue;
}
- const isConnected = this.discoveredActive.some((a) => {
- const aHost = a.target_host || a.remote || a.listen_ip;
- const aPort = a.target_port || a.listen_port;
- return aHost && aPort && disc.reachable_on === aHost && String(disc.port) === String(aPort);
- });
+ const isConnected =
+ disc.reachable_on != null &&
+ disc.port != null &&
+ activeEndpoints.has(`${disc.reachable_on}:${disc.port}`);
const angle = Math.random() * 2 * Math.PI;
const dist = 800 + Math.random() * 200;
@@ -1129,10 +1165,9 @@ export default {
to: discId,
color: {
color: isDarkMode ? "#155e75" : "#06b6d4",
- opacity: 0.4,
+ opacity: 0.35,
},
width: 1,
- dashes: true,
hidden: false,
});
processedEdgeIds.add(edgeId);
@@ -1299,10 +1334,10 @@ export default {
id: edgeId,
from: entry.interface,
to: entry.hash,
+ // Direct = brighter/thicker; multi-hop = cooler/thinner.
+ // No dashes/arrows — both force expensive per-frame path work.
color: directHop ? this.directEdgeColor(isDarkMode) : this.multiHopEdgeColor(isDarkMode),
- width: directHop ? 2 : 1,
- dashes: !directHop,
- arrows: directHop ? this.directEdgeArrows() : undefined,
+ width: directHop ? 2.5 : 1,
hidden: false,
});
processedEdgeIds.add(edgeId);
@@ -1314,12 +1349,13 @@ export default {
this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`;
/*
- * Yield to the event loop using the prioritized scheduler
- * (or setTimeout fallback). $nextTick is a microtask and does
- * not let the renderer paint or process input between chunks,
- * which is what was making the app feel frozen.
+ * Yield between chunks on large graphs so the overlay can paint.
+ * Small graphs finish in one/few chunks — skip yields so we beat
+ * upstream wall-clock on typical meshes.
*/
- await yieldToMain();
+ if (this.pathTable.length > VIZ_SYNC_PATH_THRESHOLD) {
+ await yieldToMain();
+ }
if (!isCurrentRun()) return;
}
@@ -1411,7 +1447,7 @@ export default {
.vis-tooltip {
color: #f4f4f5 !important;
- background: rgba(9, 9, 11, 0.9) !important;
+ background: rgba(9, 9, 11, 0.92) !important;
border: 1px solid rgba(63, 63, 70, 0.5) !important;
border-radius: 12px !important;
padding: 12px 16px !important;
@@ -1420,7 +1456,6 @@ export default {
font-style: normal !important;
font-family: Inter, system-ui, sans-serif !important;
line-height: 1.5 !important;
- backdrop-filter: blur(8px) !important;
pointer-events: none !important;
}
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
index 2f9a1ab7..c9b96b71 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
@@ -2,7 +2,7 @@
<template>
<div
- class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
+ class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/90 dark:bg-zinc-900/90"
>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
index c2455fd5..afaac463 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
@@ -3,7 +3,7 @@
<template>
<div
v-if="isLoading"
- class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
+ class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/15 transition-all duration-300"
>
<div
class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
index 0118a3fe..b897afca 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -5,7 +5,7 @@
class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
>
<div
- class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
+ class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/90 dark:bg-zinc-900/90 rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
>
<div
class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
@@ -213,7 +213,7 @@
:value="searchQuery"
type="text"
:placeholder="`Search nodes (${nodeCount})...`"
- class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-xs"
+ class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/90 dark:bg-zinc-900/90 border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-xs"
@input="$emit('update:searchQuery', $event.target.value)"
/>
<button
diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.js
index 2fcecf97..0a607882 100644
--- a/meshchatx/src/frontend/js/networkVisualiserPerf.js
+++ b/meshchatx/src/frontend/js/networkVisualiserPerf.js
@@ -52,6 +52,7 @@ export function dedupeIconQueueEntries(queue) {
bucket = {
cacheKey: item.cacheKey,
nodeIds: [],
+ _seen: new Set(),
iconName: item.iconName,
fg: item.fg,
bg: item.bg,
@@ -60,11 +61,12 @@ export function dedupeIconQueueEntries(queue) {
};
byKey.set(item.cacheKey, bucket);
}
- if (!bucket.nodeIds.includes(item.nodeId)) {
+ if (!bucket._seen.has(item.nodeId)) {
+ bucket._seen.add(item.nodeId);
bucket.nodeIds.push(item.nodeId);
}
}
- return Array.from(byKey.values());
+ return Array.from(byKey.values()).map(({ _seen, ...rest }) => rest);
}
/**
diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 93300402..1993f6af 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -222,7 +222,8 @@ describe("NetworkVisualiser.vue", () => {
const searchInput = wrapper.find('input[type="text"]');
await searchInput.setValue("Remote Node");
- // processVisualization is called via watcher on searchQuery
+ // searchQuery watcher debounces processVisualization
+ await new Promise((resolve) => setTimeout(resolve, 150));
await wrapper.vm.$nextTick();
// The number of nodes in the DataSet should match the search
@@ -425,7 +426,10 @@ describe("NetworkVisualiser.vue", () => {
};
wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
- wrapper.vm.pathTable = [{ hash: "node1", interface: "eth0", hops: 1 }];
+ wrapper.vm.pathTable = [
+ { hash: "node1", interface: "eth0", hops: 1 },
+ { hash: "node2", interface: "eth0", hops: 3 },
+ ];
wrapper.vm.announces = {
node1: {
destination_hash: "node1",
@@ -433,22 +437,33 @@ describe("NetworkVisualiser.vue", () => {
display_name: "Remote",
updated_at: new Date().toISOString(),
},
+ node2: {
+ destination_hash: "node2",
+ aspect: "lxmf.delivery",
+ display_name: "Far",
+ updated_at: new Date().toISOString(),
+ },
};
await wrapper.vm.processVisualization();
expect(wrapper.vm.edges.getIds()).toContain("me~eth0");
expect(wrapper.vm.edges.getIds()).toContain("eth0~node1");
+ expect(wrapper.vm.edges.getIds()).toContain("eth0~node2");
for (const edge of wrapper.vm.edges.get()) {
expect(edge.hidden).not.toBe(true);
+ expect(edge.arrows).toBeFalsy();
+ expect(edge.dashes).toBeFalsy();
}
const ifaceEdge = wrapper.vm.edges.get("me~eth0");
expect(ifaceEdge.color.color).toBe("#10b981");
- expect(ifaceEdge.arrows.to.enabled).toBe(true);
+ expect(ifaceEdge.width).toBeGreaterThanOrEqual(3);
const directPeerEdge = wrapper.vm.edges.get("eth0~node1");
expect(directPeerEdge.color.color).toBe("#10b981");
- expect(directPeerEdge.arrows.to.enabled).toBe(true);
- expect(directPeerEdge.dashes).not.toBe(true);
+ expect(directPeerEdge.width).toBeGreaterThanOrEqual(2);
+ const multiHopEdge = wrapper.vm.edges.get("eth0~node2");
+ expect(multiHopEdge.color.color).toBe("#3b82f6");
+ expect(multiHopEdge.width).toBeLessThan(directPeerEdge.width);
expect(wrapper.vm.network.redraw).toHaveBeenCalled();
});
diff --git a/tests/frontend/networkVisualiserPerf.test.js b/tests/frontend/networkVisualiserPerf.test.js
index 1a240eea..c8a41323 100644
--- a/tests/frontend/networkVisualiserPerf.test.js
+++ b/tests/frontend/networkVisualiserPerf.test.js
@@ -33,6 +33,7 @@ describe("networkVisualiserPerf", () => {
const out = dedupeIconQueueEntries(queue);
expect(out).toHaveLength(2);
expect(out.find((x) => x.cacheKey === "k1")?.nodeIds).toEqual(["n1", "n2"]);
+ expect(out.every((x) => x._seen === undefined)).toBe(true);
});
it("pickAdaptiveFetchConcurrency returns a positive integer", () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────